Skip to content
View Article Network

A Brief Discussion on Exception Handling and State Restoration for SaveChanges() in Entity Framework

This is likely the last note on Entity Framework for a while. I have been researching WSL lately, yet my notes keep ending up being about Entity Framework, making it seem like I have a grudge against it.

I originally intended to split this into two articles, but since the content is highly related and I am feeling a bit lazy, I have combined them into one.

TIP

The complete executable example for this article: CloudyWing/EfCoreBehaviorSample.

Entity Framework Exception Messages

There are three common exceptions in Entity Framework:

  • DbUpdateException: An exception thrown when an error occurs while saving to the database (e.g., violation of database constraints or other storage operation failures). This exception usually encapsulates lower-level exceptions, such as database connection errors or SQL execution errors.

  • DbUpdateConcurrencyException: An exception thrown when a concurrency issue occurs while saving to the database. This usually happens when RowVersion or ConcurrencyCheck attributes are set on an entity type to implement concurrency control. When EF discovers that the data in the database has been modified by another operation and the current operation's data version does not match, it throws this exception.

  • DbEntityValidationException: An exception thrown when SaveChanges() is called and entity validation fails. This exception is typically used to catch data validation errors in entities, such as property values not meeting data annotation requirements (e.g., [Required], [MaxLength]). It has been removed in Entity Framework Core.

TIP

I previously tried to find DbEntityValidationException while handling Entity Framework error messages, only to discover it had been removed, which was quite surprising. For the possible reasons behind its removal, you can refer to Will 保哥's post: "EF Core no longer performs additional validation on entity models during SaveChanges()". Although I believe Model Binding validation and Entity validation should be viewed separately, upon closer inspection, the benefit of Entity validation is that it checks data before writing to the database, which can reduce some overhead. However, in practice, Model Binding and Service Layer validation can block most scenarios, so the need for Entity validation is indeed rare.

To be honest, I am always bothered by the messages of EF Exceptions. For example, you might see the following:

  • EF Core's DbUpdateException message:

An error occurred while saving the entity changes. See the inner exception for details.

  • EF's DbEntityValidationException message:

Validation failed for one or more entities. See 'EntityValidationErrors' property for more details.

Who knows what the actual cause is? This makes it necessary to handle these exceptions specifically. How to handle Entity Exceptions mainly depends on whether the frontend will see the exception's error message when an exception (referring to all exceptions here) occurs:

  • When the system returns the original exception message directly to the frontend: To avoid exposing too much detail to the frontend, you should extract the full error message from the InnerException or EntityValidationErrors and record it in the log when writing the exception log. This ensures that the log contains detailed error information while the frontend only sees a generic message.
  • When the frontend cannot see the exception message: In this case, you can override the SaveChanges() method in the DbContext to catch the Entity Exception and re-throw an exception of the same type, setting the Message to the full error message. This way, the exception log does not require extra processing, making error handling and responsibility division clearer.

Restoring Entity State When SaveChanges() Fails

During data processing, database validation is usually used to ensure that abnormal data is not written, or default values are relied upon to avoid errors caused by missing data. However, in my opinion, one should not over-rely on database validation or default values, as this can lead to unexpected issues. The content of this section stems from a mistake I made many years ago.

At the time, the scheduling program used ADO.NET for data writing. To save time, the developers did not check for duplicate data before writing, relying instead on the primary key to block duplicates. When I rewrote this code into Entity Framework, I continued this approach. As mentioned in my previous article "[A Brief Discussion on Synchronizing Navigation Properties and Foreign Keys in Entity Framework](淺談 Entity Framework 的導覽屬性與外鍵的同步更新.md#%E7%AF%84%E4%BE%8B-13savechanges-%E5%A4%B1%E6%95%97)", when SaveChanges() fails, the entity state is preserved. This means that if the SaveChanges() for the first piece of data fails, when you attempt to add a second piece of data and call SaveChanges() again, the generated SQL statement will include the first piece of data. Therefore, once a failure occurs, all subsequent changes will also fail.

Of course, preserving the entity state after a SaveChanges() failure is helpful in some cases, such as failures due to network instability, allowing for a retry of SaveChanges(). I have seen projects where SaveChanges() is automatically retried up to three times until it succeeds or gives up. However, if you do not want failed changes to be preserved in specific scenarios, you can consider overriding SaveChanges() and, upon catching a DbUpdateException, restore the entity's state to ignore that specific transaction.

TIP

There are different views in the industry on whether default values should be set, mainly divided into two camps:

  • Supporting default values: Setting default values helps avoid errors when data is missing or not saved, which reduces the probability of application issues and ensures data integrity.

  • Opposing default values: Supporting setting columns to NOT NULL without default values ensures that if data is not saved correctly, the program will report an error immediately, helping developers discover and fix potential problems early and avoiding the risk of hidden errors.

The design philosophies of these two approaches differ, and neither is necessarily right or wrong. However, if the team has no specific requirements, I personally prefer the second approach.

WARNING

Note that the method of restoring Entity State after a SaveChanges() failure is only applicable to entity structures that do not contain foreign keys. The specific reasons will be explained later.

Code Implementation

I will take a shortcut here and combine the code for both sections. Since EF Core has removed DbEntityValidationException, I will not handle that part. The handling of Entity State is shown in the table below:

StateDescriptionHandling Method
DetachedNot tracked.No action needed.
UnchangedRetrieved from the database and not modified.No action needed.
DeletedRetrieved from the database and removed using Remove.Change State to Unchanged.
ModifiedRetrieved from the database and properties modified.Change State to Unchanged and restore data using entry.CurrentValues.SetValues(entry.OriginalValues).
AddedData existing only locally.Change State to Detached.
csharp
public partial class TestEFContext {
    public override int SaveChanges() {
        return SaveChanges(true);
    }

    public override int SaveChanges(bool acceptAllChangesOnSuccess) {
        try {
            return base.SaveChanges(acceptAllChangesOnSuccess);
        } catch (DbUpdateException ex) {
            throw ResetEntityStateAndFixMessage(ex);
        }
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) {
        return SaveChangesAsync(true, cancellationToken);
    }

    public override async Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess,
        CancellationToken cancellationToken = default
    ) {
        try {
            return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
        } catch (DbUpdateException ex) {
            throw ResetEntityStateAndFixMessage(ex);
        }
    }

    private DbUpdateException ResetEntityStateAndFixMessage(DbUpdateException ex) {
        ResetEntityStates(ChangeTracker.Entries());

        return new DbUpdateException(ex.InnerException.Message, ex);
    }

    private static void ResetEntityStates(IEnumerable<EntityEntry> entries) {
        foreach (EntityEntry entry in entries) {
            ResetEntityState(entry);
        }
    }

    private static void ResetEntityState(EntityEntry entry) {
        switch (entry.State) {
            case EntityState.Added:
                entry.State = EntityState.Detached;
                break;
            case EntityState.Modified:
                entry.CurrentValues.SetValues(entry.OriginalValues);
                entry.State = EntityState.Unchanged;
                break;
            case EntityState.Deleted:
                // Under normal circumstances, the deleted EntityState should be set to Unchanged.
                // However, in reality, whether set to Unchanged or Detached, an Entity removed via Remove() 
                // cannot be added back to the navigation property.
                // Setting EntityState to Unchanged might cause the navigation property to still lack the 
                // previously removed Entity when re-querying data.
                // Therefore, for related entities, set EntityEntry.State to EntityState.Detached.
                // This ensures that the navigation property is normal when re-querying data.
                entry.State = entry.Entity is Dictionary<string, object>
                    ? EntityState.Detached
                    : EntityState.Unchanged;
                break;
        }
    }
}

Test Results

When an entity is added via a navigation property, it is also added to tracking. Therefore, the problem is more likely to occur when removing associations. Thus, use the following test code to test the scenario of removing associations:

The entity structure is as follows:

csharp
modelBuilder.Entity<Table1>(entity => {
    entity.ToTable("Table1");

    entity.Property(e => e.Id).ValueGeneratedNever();

    entity.HasMany(d => d.Table2s)
        .WithMany(p => p.Table1s)
        .UsingEntity<Dictionary<string, object>>(
            "TableRef",
            l => l.HasOne<Table2>().WithMany().HasForeignKey("Table2Id").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_TableRef_Table2"),
            r => r.HasOne<Table1>().WithMany().HasForeignKey("Table1Id").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_TableRef_Table1"),
            j => {
                j.HasKey("Table1Id", "Table2Id").HasName("PK_Table_3");

                j.ToTable("TableRef");
            });
});

modelBuilder.Entity<Table2>(entity => {
    entity.ToTable("Table2");

    entity.Property(e => e.Id).ValueGeneratedNever();
});

public partial class Table1 {
    public Table1() {
        Table2s = new HashSet<Table2>();
    }

    public long Id { get; set; }

    public virtual ICollection<Table2> Table2s { get; set; }
}

public partial class Table2 {
    public Table2() {
        Table1s = new HashSet<Table1>();
    }

    public long Id { get; set; }

    public virtual ICollection<Table1> Table1s { get; set; }
}

Existing database data: Table1

Id
1
2
3

Table2

Id
1
2

TableRef

Table1IdTable2Id
11
22

Use the following code to test:

csharp
using TestEFContext dbContext = new(dbContextOptions);
// Retrieve records from Table1 and Table2, including related navigation properties
Table1 table11 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 1);
Table1 table12 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 2);
Table2 table21 = dbContext.Table2s.Include(x => x.Table1s).Single(x => x.Id == 1);
Table2 table22 = dbContext.Table2s.Include(x => x.Table1s).Single(x => x.Id == 2);

PrintLog();

table11.Table2s.Remove(table21);

PrintLog();

table12.Table2s.Add(table21);

PrintLog();

try {
    // Intentionally trigger a primary key conflict error by attempting to insert an existing Table1 record
    dbContext.Add(new Table1 {
        Id = 3
    });

    dbContext.SaveChanges();
} catch (Exception) {
}

PrintLog();

table11 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 1);
Console.WriteLine($"table11's Table2s association count: {table11.Table2s.Count}");

void PrintLog() {
    foreach (EntityEntry entry in dbContext.ChangeTracker.Entries()) {
        Console.WriteLine(entry.ToString());
    }

    // Output the association count between each Table1 and Table2
    Console.WriteLine($"table11's Table2s association count: {table11.Table2s.Count}");
    Console.WriteLine($"table12's Table2s association count: {table12.Table2s.Count}");
    Console.WriteLine($"table21's Table1s association count: {table21.Table1s.Count}");
    Console.WriteLine($"table22's Table1s association count: {table22.Table1s.Count}");

    Console.WriteLine();
}

The execution results are as follows:

text
Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Unchanged FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 1
table12's Table2s association count: 1
table21's Table1s association count: 1
table22's Table1s association count: 1

Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Deleted FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 0
table12's Table2s association count: 1
table21's Table1s association count: 0
table22's Table1s association count: 1

Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 1} Added FK {Table1Id: 2} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Deleted FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 0
table12's Table2s association count: 2
table21's Table1s association count: 1
table22's Table1s association count: 1

Exception error

Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 0
table12's Table2s association count: 1
table21's Table1s association count: 0
table22's Table1s association count: 1

From the results, adding or removing associations via navigation properties does not affect the Entity State, but an EntityEntry for the association is actually generated. However, when restoring the EntityEntry.State of the association, only the Add() change is restored, while the Remove() part is not handled.

Regarding the handling of the EntityState.Deleted scenario on line 50 of TestEFContext, the differences in results based on how the TableRef Entity State is handled are explained below:

  • TableRef count:

    • EntityState.Unchanged: There will be two records in TableRef, which is the same as the situation before Remove(), and this result is correct.

    • EntityState.Detached: There is only one record in TableRef, missing TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Unchanged FK {Table1Id: 1} FK {Table2Id: 1}.

  • table11.Table2s.Count: Both are 0.

  • When re-retrieving the data for Table1.Id of 1 from the database:

    • EntityState.Unchanged: Table2s.Count is still 0. It is speculated that this is related to the DbContext caching mechanism mentioned in "EF Core DbContext Cache Experiment" and "How Query Works". Although data is queried from the database, because the DbContext already contains the data and it is tracked, the entity in the DbContext is returned directly. Honestly, this looks like a bug to me...

    • EntityState.Detached: Table2s.Count will be 1, and the navigation property successfully re-obtains data from the database.

Although setting it to EntityState.Detached seems slightly better in usage, there are problems in both cases. Therefore, it is not recommended to use entity state restoration when foreign keys are present.

WARNING

Using DbSet.Add() to add an entity with the same PK as already queried data will throw an InvalidOperationException. Since the exception is thrown at Add() rather than SaveChanges(), it will not be caught by the existing error handling mechanism.

Change Log

  • 2026-05-29 Added link to the corresponding GitHub sample project.
  • 2024-08-17 Initial version created.